프로그래머스 데브매칭 고양이 정리

JIGGLYPOP

염동환


새로운 개발을 좋아하는 개발자

2021-03-01 12:23 시에 저장한 글입니다.

1. App

import SearchInput from "./components/SearchInput.js";
import SearchResult from "./components/SearchResult.js";
import ImageInfo from "./components/ImageInfo.js";
import Spinner from "./components/Spinner.js";
import Tags from "./components/Tags.js";
import DarkButton from "./components/DarkButton.js";
import Banner from "./components/Banner.js";
import throttling from "./utils/throttle.js";
import spinnerToggle from "./utils/spinnerToggle.js";

import api from "./api/api.js";

export default class App {
  $target = null;
  data = [];
  tags = [];

  constructor($target) {
    this.$target = $target;
    // 스피너
    this.spinner = new Spinner({
      $target,
    });

    // 검색
    this.searchInput = new SearchInput({
      $target,
      onSearch: async (keyword) => {
        spinnerToggle();
        this.tags.setTags(keyword);
        const res = await api.fetchCats(keyword);
        if (!res.isError) {
          this.setSearchData(res.data);
        } else {
          this.setError(res.data);
        }
        spinnerToggle();
      },

      onRandom: async () => {
        spinnerToggle();
        const res = await api.fetchRandoms();
        if (!res.isError) {
          this.setState(res.data);
        } else {
          this.setError(res.data);
        }
        spinnerToggle();
      },
    });
    // 다크모드
    this.darkbutton = new DarkButton({
      $target,
    });
    // 태그
    this.tags = new Tags({
      $target,
      onClickTag: async (keyword) => {
        spinnerToggle();
        const res = await api.fetchCats(keyword);
        if (!res.isError) {
          this.setSearchData(res.data);
        } else {
          this.setError(res.data);
        }
        spinnerToggle();
      },
    });
    // 배너
    this.banner = new Banner({
      $target,
      onRandom: async () => {
        spinnerToggle();
        const res = await api.fetchRandoms();
        if (!res.isError) {
          this.setState(res.data);
        } else {
          this.setError(res.data);
        }
        spinnerToggle();
      },
    });
    // 결과리스트
    const throttle = throttling();
    const scroll = () => {
      const {
        scrollTop,
        scrollHeight,
        clientHeight,
      } = document.documentElement;
      if (scrollTop + clientHeight >= scrollHeight - 500) {
        throttle.throttle(async () => {
          spinnerToggle();
          this.setDummy();
          const res = await api.fetchRandoms();
          if (!res.isError) {
            this.setScroll(res.data);
          } else {
            this.setError(res.data);
          }
          this.setDummy();
          spinnerToggle();
        });
      }
    };

    this.searchResult = new SearchResult({
      $target,
      initialData: this.data,
      onClick: (data) => {
        this.imageInfo.setState({
          visible: true,
          data,
        });
      },
      onScroll: async (isRandom) => {
        if (isRandom) {
          window.addEventListener("scroll", scroll);
        } else {
          window.removeEventListener("scroll", scroll);
        }
      },
    });
    // 모달
    this.imageInfo = new ImageInfo({
      $target,
      data: {
        visible: false,
        image: null,
      },
    });
  }

  setState(nextData) {
    this.data = nextData;
    this.banner.setState(nextData);
    this.searchResult.setState(nextData);
  }

  setSearchData(data) {
    this.banner.setState(data);
    this.searchResult.setSearchData(data);
  }

  setScroll(data) {
    this.data.push(...data);
    this.banner.setState(this.data);
    this.searchResult.setState(this.data);
  }

  setDummy() {
    this.searchResult.dummyToggle();
  }

  setError(errorData) {
    this.searchResult.setError(errorData);
  }
}

2. style.css

@font-face {
  font-family: "Goyang";
  src: url("fonts/Goyang.woff") format("woff");
  font-weight: normal;
  font-style: normal;
}

html {
  box-sizing: border-box;
}

body * {
  font-family: Goyang;
}

*,
*:before,
*:after {
  box-sizing: inherit;
}

#App {
  margin: 1.5em auto;
  max-width: 1200px;
  column-gap: 1.5em;
}

.SearchResult {
  margin-top: 10px;
  display: grid;
  grid-template-columns: repeat(4, minmax(250px, 1fr));
  grid-gap: 10px;
}

@media only screen and (max-width: 992px) {
  .SearchResult {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media only screen and (max-width: 768px) {
  .SearchResult {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media only screen and (max-width: 576px) {
  .SearchResult {
    grid-template-columns: 1fr;
  }
}

.SearchResult img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.SearchResult .img-outer {
  width: 100%;
  height: 300px;
}

.SearchResult .item {
  background-color: #eee;
  display: inline-block;
  margin: 0 0 1em;
  width: 100%;
}

.SearchInput {
  width: 100%;
  font-size: 40px;
  padding: 10px 15px;
}

.ImageInfo {
  position: fixed;
  z-index: -1;
  opacity: 0;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.5);
  transition: opacity 0.3s;
}
.tooltip {
  z-index: -1;
  opacity: 0;
  transition: opacity 0.3s;
}

.fade-in {
  z-index: 1;
  opacity: 1;
}

.fade-out {
  z-index: -1;
  opacity: 0;
}

.ImageInfo .title {
  display: flex;
  justify-content: space-between;
}

.ImageInfo .title,
.ImageInfo .description {
  padding: 5px;
}

.ImageInfo .content-wrapper {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  background-color: #fff;
  border: 1px solid #eee;
  border-radius: 5px;
}

.ImageInfo .content-wrapper img {
  width: 100%;
}

.spinner-wrapper {
  position: fixed;
  display: flex;
  justify-content: center;
  align-items: center;
  text-align: center;
  color: white;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.5);
}

#dummy {
  width: 100vw;
  height: 1000px;
}

.isvisible {
  display: none;
}

.imgs-container img {
  width: 100px;
  height: 100px;
}

.imgs-container.banner-outer {
  width: 100px;
  height: 100px;
}

/* 캐러셀 */
.imgs-container {
  display: flex;
  transform: translate3d(0);
  transition: transform 0.5s ease-in-out;
}

.carousel {
  width: 500px;
  overflow: hidden;
}

/* dark mode 처리 */

@media (prefers-color-scheme: dark) {
  body {
    transition: all 0.5s ease-in-out;
    background-color: #000;
    color: #fff;
  }
}

body[data-theme="light"] {
  transition: all 0.5s ease-in-out;
  background-color: #fff;
  color: #000;
}

body[data-theme="dark"] {
  transition: all 0.5s ease-in-out;
  background-color: #000;
  color: #fff;
}

3. cache

const cache = {
  get(key) {
    const data = JSON.parse(localStorage.getItem(key));
    return data;
  },
  set(key, data) {
    localStorage.setItem(key, JSON.stringify(data));
  },
};

export default cache;

4. debounce

const debouncing = () => {
  let timer;
  return {
    debounce(callback) {
      if (!timer) clearTimeout(timer);
      timer = setTimeout(() => {
        callback(...arguments);
      }, 500);
    },
  };
};

export default debouncing;

5. spinnerToggle

const spinnerToggle = () => {
  const spinner = document.querySelector(".spinner-wrapper");
  spinner.classList.toggle("isvisible");
};
export default spinnerToggle;

6. throttle

const throttling = () => {
  let timer;
  return {
    throttle(callback) {
      if (!timer) {
        timer = setTimeout(() => {
          callback(...arguments);
          timer = false;
        }, 500);
      }
    },
  };
};

export default throttling;

7. banner

export default class Banner {
  data = [];
  onRandom = null;

  constructor({ $target, onRandom }) {
    const $outer = document.createElement("div");
    $outer.className = "carousel";
    this.$outer = $outer;
    this.onRandom = onRandom;
    $target.appendChild(this.$outer);
    this.onRandom();
  }

  setState(nextData) {
    this.data = nextData;
    this.render();
  }

  render() {
    this.$outer.innerHTML = "";
    const imgsContainer = document.createElement("div");
    imgsContainer.className = "imgs-container";
    imgsContainer.innerHTML = this.data
      .map(
        (img) => `
          <div class="banner-outer">
            <img src="${img.url}" class="banner-img"/>
          </div>
        `
      )
      .join("");

    // 넘기기
    const imgs = imgsContainer.getElementsByTagName("img");
    let idx = 0;
    const changeImg = () => {
      if (idx > imgs.length - 5) {
        idx = 0;
      } else if (idx < 0) {
        idx = imgs.length - 5;
      }
      imgsContainer.style.transform = `translateX(${-idx * 100}px)`;
    };

    const leftBtn = document.createElement("button");
    leftBtn.className = "left-btn";
    leftBtn.innerText = "이전";
    leftBtn.addEventListener("click", () => {
      idx--;
      changeImg();
    });

    const rightBtn = document.createElement("button");
    rightBtn.className = "right-btn";
    rightBtn.innerText = "다음";
    rightBtn.addEventListener("click", () => {
      idx++;
      changeImg();
    });
    this.$outer.appendChild(imgsContainer);
    this.$outer.appendChild(leftBtn);
    this.$outer.appendChild(rightBtn);
  }
}

8. darkbutton

export default class DarkButton {
  constructor({ $target }) {
    const $outer = document.createElement("div");
    $outer.className = "darkmode";
    this.$outer = $outer;
    $target.appendChild(this.$outer);
    this.render();
  }

  render() {
    let originTheme = document.body.dataset.theme;
    if (!originTheme) {
      originTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
        ? "dark"
        : "light";
      document.body.setAttribute("data-theme", originTheme);
    }

    const btn = document.createElement("button");
    btn.id = "darkmode-btn";
    btn.innerText = "다크모드";
    btn.addEventListener("click", () => {
      if (originTheme === "dark") {
        originTheme = "light";
        document.body.setAttribute("data-theme", "light");
      } else {
        originTheme = "dark";
        document.body.setAttribute("data-theme", "dark");
      }
    });
    this.$outer.appendChild(btn);
  }
}

9. imageinfo

import api from "../api/api.js";
import spinnerToggle from "../utils/spinnerToggle.js";

export default class ImageInfo {
  $imageInfo = null;
  data = null;
  catdata = null;
  caterror = null;

  constructor({ $target, data }) {
    const $imageInfo = document.createElement("div");
    $imageInfo.className = "ImageInfo";
    $imageInfo.id = "image-info";
    this.$imageInfo = $imageInfo;

    $target.appendChild($imageInfo);
    this.data = data;
    this.render();
  }

  async onCatById(id) {
    const res = await api.fetchCatById(id);
    if (!res.isError) {
      this.catdata = res.data;
    } else {
      this.caterror = res.data;
    }
  }

  async setState(data) {
    spinnerToggle();
    this.data = data;
    await this.onCatById(this.data.data);
    spinnerToggle();
    this.render();
  }

  onClose() {
    this.data.visible = false;
    this.fadeOut();
    this.render();
  }

  fadeIn() {
    this.$imageInfo.classList.add("fade-in");
    this.$imageInfo.classList.remove("fade-out");
  }

  fadeOut() {
    this.$imageInfo.classList.add("fade-out");
    this.$imageInfo.classList.remove("fade-in");
  }

  render() {
    if (this.data.visible) {
      this.fadeIn();
      this.$imageInfo.innerHTML = `
        <div class="content-wrapper">
          <div class="title">
            <span>${this.catdata.name}</span>
            <div class="close">x</div>
          </div>
          <img src="${this.catdata.url}" alt="${this.catdata.name}"/>        
          <div class="description">
            <div>성격: ${this.catdata.temperament}</div>
            <div>태생: ${this.catdata.origin}</div>
          </div>
        </div>`;

      this.$imageInfo.addEventListener("click", (e) => {
        if (e.target.id === "image-info" || e.target.className === "close") {
          this.onClose();
        }
      });
      window.addEventListener("keyup", (e) => {
        if (this.data.visible && e.key === "Escape") {
          this.onClose();
        }
      });
    } else {
      this.fadeOut();
    }
  }
}

10. searchinput

export default class SearchInput {
  constructor({ $target, onSearch, onRandom }) {
    const $searchInput = document.createElement("section");
    this.$searchInput = $searchInput;
    this.onRandom = onRandom;
    this.onSearch = onSearch;
    $target.appendChild($searchInput);
    this.render();
  }

  render() {
    this.$searchInput.innerHTML = `
    <div class="search-wrapper">
      <span>
        <button class="random-button">고양이</button>
      </span>
      <input class="SearchInput" placeholder="고양이를 검색해보세요.|" />
    </div>`;
    const searchinput = document.querySelector(".SearchInput");
    searchinput.focus();
    this.$searchInput.addEventListener("keyup", (e) => {
      if (e.target.value !== "") {
        if (e.keyCode === 13) {
          this.onSearch(e.target.value);
          e.target.value = "";
        }
      }
    });

    const randombutton = document.querySelector(".random-button");
    randombutton.addEventListener("click", () => {
      this.onRandom();
    });
  }
}

11. searchresult

export default class SearchResult {
  $searchResult = null;
  $dummy = null;
  data = null;
  isRandom = true;
  error = null;
  onClick = null;
  onScroll = null;

  constructor({ $target, initialData, onClick, onScroll }) {
    this.$searchResult = document.createElement("div");
    this.$searchResult.className = "SearchResult";

    this.$searchResult.addEventListener("click", (e) => {
      this.onClick(e.target.id);
    });

    this.$dummy = document.createElement("div");
    this.$dummy.classList.add("isvisible");
    this.$dummy.id = "dummy";
    $target.appendChild(this.$searchResult);
    $target.appendChild(this.$dummy);

    this.data = initialData;
    this.onClick = onClick;
    this.onScroll = onScroll;
    this.render();
  }

  setState(nextData) {
    this.isRandom = true;
    this.data = nextData;
    this.error = null;
    this.render();
  }

  setError(errorData) {
    this.data = null;
    this.error = errorData.message;
    this.render();
  }

  setSearchData(nextData) {
    this.isRandom = false;
    this.data = nextData;
    this.error = null;
    this.render();
  }

  dummyToggle() {
    const dummy = document.querySelector("#dummy");
    dummy.classList.toggle("isvisible");
  }

  render() {
    if (this.data) {
      this.onScroll(this.isRandom);
      if (!this.isRandom && this.data.length === 0) {
        this.$searchResult.innerHTML = `<h1>검색 결과가 없습니다</h1>`;
        return;
      } else {
        if ("IntersectionObserver" in window) {
          const lazyimage = new IntersectionObserver((entries) => {
            entries.forEach((entry) => {
              if (entry.isIntersecting) {
                const outer = entry.target;
                const img = outer.querySelector("img");
                const src = img.getAttribute("lazy");
                img.setAttribute("src", src);
                lazyimage.unobserve(outer);
              }
            });
          });

          const makeCat = (cat) => {
            const text = `
              <div class="item">
                <div class="img-outer">
                  <img lazy=${cat.url} alt=${cat.name} id=${cat.id} />
                </div>
                <h6 class="tooltip">${cat.name}</h6>
              </div>
            `;
            return text;
          };

          this.$searchResult.innerHTML = this.data
            .map((cat) => makeCat(cat))
            .join("");

          this.$searchResult.querySelectorAll(".item").forEach(($item) => {
            const tooltip = $item.querySelector(".tooltip");

            $item.addEventListener("mouseover", () => {
              tooltip.classList.add("fade-in");
              tooltip.classList.remove("fade-out");
            });
            $item.addEventListener("mouseout", () => {
              tooltip.classList.add("fade-out");
              tooltip.classList.remove("fade-in");
            });
            lazyimage.observe($item);
          });
        }
      }
    } else if (this.error) {
      this.$searchResult.innerHTML = `
        <h1>${this.error}</h1>
      `;
    }
  }
}

12. spinner

export default class Spinner {
  $spinner = null;

  constructor({ $target }) {
    this.$spinner = document.createElement("div");
    this.$spinner.className = "spinner-wrapper";
    $target.appendChild(this.$spinner);
    this.render();
  }
  render() {
    this.$spinner.classList.add("isvisible");
    this.$spinner.innerHTML = `
      <span>
        <h1>잠시만 기다려주세요</h1>
      </span>`;
  }
}

13. tags

export default class Tags {
  tags = [];
  onClickTag = null;

  constructor({ $target, onClickTag }) {
    const $outer = document.createElement("div");
    $outer.className = "tags";
    this.$outer = $outer;
    this.onClickTag = onClickTag;
    $target.appendChild(this.$outer);
    this.render();
  }

  setTags(tag) {
    if (this.tags.indexOf(tag) === -1) {
      if (this.tags.length >= 5) {
        this.tags.shift();
      }
      this.tags.push(tag);
    }
    this.render();
  }

  render() {
    this.$outer.innerHTML = "";
    this.tags.forEach((keyword) => {
      const tag = document.createElement("div");
      tag.className = "tag-item";
      tag.innerHTML = `<h4>${keyword}</h4>`;
      tag.addEventListener("click", () => {
        this.onClickTag(keyword);
      });
      this.$outer.appendChild(tag);
    });
  }
}

14. api

import cache from "../utils/cache.js";

const API_ENDPOINT =
  "https://oivhcpn8r9.execute-api.ap-northeast-2.amazonaws.com/dev";

const errorMsg = (res) => {
  if (res.status < 300) return false;
  if (res.status < 400) {
    return `리다이렉트 에러: ${res.status}`;
  }
  if (res.status < 500) {
    return `클라이언트 에러: ${res.status}`;
  }
  if (res.status < 600) {
    return `서버 에러: ${res.status}`;
  }
};

const api = {
  fetchCats: async (keyword) => {
    try {
      const cachedata = cache.get(keyword);
      if (cachedata) {
        return {
          isError: false,
          data: cachedata,
        };
      }
      const res = await fetch(`${API_ENDPOINT}/api/cats/search?q=${keyword}`);
      if (res.ok) {
        const data = await res.json();
        cache.set(keyword, data.data);
        return {
          isError: false,
          data: data.data,
        };
      } else {
        const error = errorMsg(res);
        throw error;
      }
    } catch (e) {
      return {
        isError: true,
        data: e,
      };
    }
  },

  fetchRandoms: async () => {
    try {
      const res = await fetch(`${API_ENDPOINT}/api/cats/random50`);
      if (res.ok) {
        const data = await res.json();
        return {
          isError: false,
          data: data.data,
        };
      } else {
        const error = errorMsg(res);
        throw error;
      }
    } catch (e) {
      return {
        isError: true,
        data: {
          message: e.message,
          status: e.status,
        },
      };
    }
  },

  fetchCatById: async (id) => {
    try {
      const res = await fetch(`${API_ENDPOINT}/api/cats/${id}`);
      if (res.ok) {
        const data = await res.json();
        return {
          isError: false,
          data: data.data,
        };
      } else {
        const error = errorMsg(res);
        throw error;
      }
    } catch (e) {
      return {
        isError: true,
        data: {
          message: e.message,
          status: e.status,
        },
      };
    }
  },
};
export default api;